<?php
namespace Tlf\Tester;
class Runner extends \Tlf\Cli {
public $server_port = [];
/**
* Load all commands onto the cli
* @param $cli \Tlf\Cli cli lib
*/
public function setup_commands(\Tlf\Cli $cli){
$commands = [
// command => [method_name, Help Text]
'main'=>['run_dir', "Run Tests"],
'init' => ['init', "Initialize test directory"],
'server' => ['start_server', "Start test server. Optionally pass configured server name as first arg."],
'print-config' => ['print_config', "Print runtime configuration (on-disk + defaults + cli args]"],
];
foreach ($commands as $command => $method_help){
$cli->load_command($command, [$this, $method_help[0]], $method_help[1]);
}
}
/**
* Initialize the runner and return array of cli arguments,
*
* @param $cli \Tlf\Cli cli lib
* @param $args array of cli args + configs
* @return array of args to use for cli
*/
public function initialize(\Tlf\Cli $cli, array $args): array {
return $args;
}
/**
* Load config file from disk
*
* @param $cli \Tlf\Cli cli lib
* @param $dir working directory from which to load a config file
* @param $args array of cli args + configs
*/
public function load_configs(\Tlf\Cli $cli, string $dir, array $args){
$locations = [
'.config/phptest.json',
'config/phptest.json',
'test/config.json',
];
foreach ($locations as $file){
$path = $dir.'/'.$file;
if (is_file($path)){
$configs = json_decode(file_get_contents($path),true);
if (!is_array($configs)){
error_log("File '$file' does not contain valid json");
} else {
$cli->load_inputs($configs);
}
}
}
}
/**
* get the server host
* @if called by `phptest server`, @then generate random port & write to file
* @if called by running `->get()` tests, @then load port from file
*
*/
public function get_server_host(string $name='main'): string{
$host = $this->args['host.override'] ?? $this->args['server'][$name]['host'] ?? 'http://localhost';
if ($host=='http://localhost')$host .= ":".$this->get_host_port($name);
else if ($host=='https://localhost')$host .= ":".$this->get_host_port($name);
return $host;
}
public function get_host_port(string $server_name): int {
if (!isset($this->args['server'][$server_name])){
$this->report("Server '$server_name' is not configured. Cannot create/load host port.");
throw new \Exception("Server '$server_name' is not configured. Cannot create/load host port.");
return 0;
}
$server = $this->args['server'][$server_name];
$dir = $this->pwd.'/'.$server['dir'];
$port_file = $dir.'/.phptest-port';
if (file_exists($port_file)){
return (int)trim(file_get_contents($port_file));
}
if ($this->command != 'server'){
$dir_name = $server['dir'];
$msg = "Host is not stored. File '$dir_name/.phptest-port' should contain an integer port number.";
$this->report($msg);
throw new \Exception($msg);
}
$port = random_int(3001, 5000);
file_put_contents($port_file, $port);
return $port;
}
public function print_config(\Tlf\Cli $cli, array $args){
echo "\n\n";
echo json_encode($args, JSON_PRETTY_PRINT);
echo "\n\n";
}
/**
* Start a localhost server for testing
* @warning this early implementation will change
*/
public function start_server(\Tlf\Cli $cli, array $args){
$server_name = $args['--'][0] ?? $args['server.main'];
if (!isset($args['server'][$server_name])){
$this->report("Test server '$server_name' not configured.");
return false;
}
$server = $args['server'][$server_name];
$host = $this->get_server_host($server_name);
$dir = $server['dir'];
$dir = str_replace("//","/", $dir);
if (substr($dir,0,1)=='/')$dir= substr($dir,1);
if ($dir == "")$dir = "./";
$deliver = $dir.'/'.($server['deliver'] ?? 'deliver.php');
$deliver = str_replace("//","/", $deliver);
$bootstrap = $dir.'/'.($server['bootstrap'] ?? 'bootstrap.php');
$deliver_absolute = getcwd().'/'.$deliver;
//var_dump($host);
//var_dump($deliver);
if (!file_exists($deliver_absolute)){
$this->report("No Deliver script found for server '$server_name'");
return false;
}
if (file_exists($bootstrap)){
$this->report("`require()` $bootstrap");
require($bootstrap);
}
$host = str_replace(["http://", "https://"], "", $host);
$command = "php -S $host -t $dir $deliver";
$this->report("Execute:\n $command\n\n");
system($command);
}
/**
* Copies sample test files into `getcwd().'/test'`
*/
public function init(\Tlf\Cli $cli, array $args){
$dir = getcwd().'/test';
$continue = readline("Initialize test dir at $dir? (y/n) ");
if ($continue!=='y')return;
mkdir($dir);
mkdir($dir.'/run');
mkdir($dir.'/src');
mkdir($dir.'/input');
mkdir($dir.'/Server');
mkdir($dir.'/input/Compare');
$files = [
'run/Compare.php',
'run/Server.php',
"Tester.php",
'input/Compare/Compare.php',
'input/Compare/Compare.txt',
'Server/deliver.php',
'bootstrap.php',
'config.json',
];
foreach ($files as $f){
$dest = $dir.'/'.$f;
if (file_exists($dest)){
echo "\nSkip: $dest";
} else {
echo "\nCreate: $dest";
copy(dirname(__DIR__).'/test/'.$f, $dir.'/'.$f);
}
}
}
/**
* Require every php file within a directory
* @param $path string absolute path to directory
*/
public function require_directory(string $path){
foreach (scandir($path) as $f){
if ($f=='.'||$f=='..')continue;
if (is_dir($path.'/'.$f)){
$this->require_directory($path.'/'.$f);
} else if (substr($f,-4)=='.php'){
(function() use ($path, $f){
require_once($path.'/'.$f);
})();
}
}
}
/**
* executes all tests inside test directories (config `dir.test`)
*
* @param $cli
* @param $args
*
* @return array<string key, mixed value> test info w/ entries pass, fail, disabled, tests_run, class, assersions_pass, assersions_fail
*
*/
public function run_dir(\Tlf\Cli $cli, array $args): array {
$info = [
'pass'=>0,
'fail'=>0,
'tests_run'=>0,
'disabled'=>0,
'assertions_pass'=>0,
'assertions_fail'=>0,
'failed_tests' => '',
];
$dir = $cli->pwd;
/** @config file.require array<int index, string rel_file_name> - Array of files to require before running tests. */
$required_files = $args['file.require'];
/** @config dir.test string - relative path to directory containing tests */
$dir_to_test = $args['dir.test'];
if (is_string($dir_to_test)){
error_log("test config 'dir.test' should be an array, not a string");
$dir_to_test = [$dir_to_test];
}
/** @config dir.exclude array<int index, string rel_dir_path> - relative directory paths within the test dir that should not be run. Relative to current working directory. */
$dirs_to_exclude = $args['dir.exclude'];
/** @argv class array<int index, string class_name> - Classes to test. If set, no other classes will be run. If not set, all test classes are run */
$classes_to_test = $args['class'] ?? [];
// @bugfix for some reason args['class'] isn't an array ... it should be... idk when I broke it, but this is quickfix
if (is_string($classes_to_test))$classes_to_test = [$classes_to_test];
/////
// load required files & directories
/////
foreach ($required_files as $file){
$path = $dir.'/'.$file;
// var_dump($path);
if (is_dir($path)){
$this->require_directory($path);
} else if (is_file($path)){
(function() use ($path){
require_once($path);
})();
} else {
$this->error("File '$file' does not exist in '{$cli->pwd}'");
}
}
/////
// build array of testable classes
/////
$phpFiles = $this->get_php_files($dir, $dir_to_test);
$excludes = $dirs_to_exclude;
/** array<string absolute_file_path, string class_name> */
$test_files = [];
foreach ($phpFiles as $relPath){
if ($this->is_excluded($excludes, $relPath))continue;
$filePath = $dir.'/'.$relPath;
$class = $this->get_test_class($filePath);
$class_base = substr($class??'', strrpos($class??'', '\\')+1);
if (count($classes_to_test)>0&&!in_array($class_base, $classes_to_test))continue;
$test_files[$filePath] = $class;
}
/////
// Run tests & record results
/////
$tests = [];
foreach ($test_files as $file=>$class){
if (!is_string($class))continue;
$results = $this->test_class($class, $args,$cli);
if ($results===false)continue;
$info['pass']+=$results['pass'];
$info['fail']+=$results['fail'];
$info['disabled']+=$results['disabled'];
$info['tests_run']+=$results['tests_run'];
$info['class'][$class] = $results;
$info['assertions_pass']+=$results['assert_pass'];
$info['assertions_fail']+=$results['assert_fail'];
if (count($results['failed_tests']) > 0){
$info['failed_tests'] .=
"\n**".$class.":: "
.implode(', ', $results['failed_tests']).",";
}
// print_r($results['assertion_count']);
}
echo "\n";
ob_start();
echo "\nTests: ".$info['tests_run'];
echo "\nPass: ".$info['pass'];
echo "\nFail: ".$info['fail'];
echo "\nDisabled: ".$info['disabled'];
echo "\n\nAssertions Passed: ".$info['assertions_pass'];
echo "\nAssertions Failed: ".$info['assertions_fail'];
echo "\n########Tests Failed: ".$info['failed_tests'];
$results_text = ob_get_clean();
echo $results_text;
echo "\n";
$log_file = $args['file.log_to'] ?? false;
if ($log_file!==false){
$fh = fopen($log_file,'a');
fwrite($fh,
"\n".date(DATE_RFC2822).": "
.str_replace("########", "\n\n ",
str_replace("\n"," *", $results_text)
)."\n"
);
fclose($fh);
}
return $info;
}
/**
* Run tests on a class
*
* @param $class string - fully qualified class name
* @param $args array - cli args + configs
* @param $cli \Tlf\Cli - cli lib
*
* @return array test results as an array
*/
public function test_class(string $class, array $args,\Tlf\Cli $cli): array {
// $ob_level = Utility::startOb();
//run the test class
if (!class_exists($class??'',true))return false;
$tester = new $class($args, $cli);
$results = $tester->run();
// $output = Utility::endOb($ob_level);
return $results;
}
/**
* Get all php files for testing. @see(get_test_class) is used to filter out files that don't contain a test class.
*
* @param string $dir the root directory for the project
* @param array $sub_dirs the sub-directories where files should be searched for
*/
public function get_php_files(string $dir, array $sub_dirs){
// find all files that need testing
$files = [];
foreach ($sub_dirs as $sub_dir){
$search_dir = $dir.'/'.$sub_dir;
$files = array_merge($files, \Tlf\Tester\Utility::getAllFiles($search_dir,$dir,'.php'));
}
return $files;
}
/**
* Check if a file is excluded from testing
*
* @param $excludes generally, the 'dir.excludes' config
* @param $relPath the relative path of a file that we're checking for
* @return bool true or false
*/
public function is_excluded(array $excludes, string $relPath): bool{
//check if file is excluded from testing
foreach ($excludes as $e){
$re = $relPath;
if ($re[0]!='/')$re = '/'.$re;
if ($e[0]!='/')$e = '/'.$e;
if (substr($relPath,0,strlen($e))==$e)return true;
}
if (in_array($relPath, $excludes))return true;
return false;
}
/**
* Get name of class in file. The class in the file must be a subclass of \Tlf\Tester
*
* @param $filePath string the path to the file containing a test class
* @return class name or null
* @side_effect require_once the file
*/
public function get_test_class(string $filePath): ?string {
(function() use ($filePath){
require_once($filePath);
})();
$class = Utility::getClassFromFile($filePath);
if ($class==null){
$this->report("No class found in $filePath");
return null;
}
if (!is_a($class, '\\Tlf\\Tester', true))return null;
return $class;
}
/**
* echo the message
*
* @param $msg string
*/
public function report(string $msg){
echo "\n$msg\n";
}
}